game_manager_lib\services\recommendation/
analysis.rs

1//! Sistema de Análise e Debug de Recomendações
2//!
3//! Este módulo fornece ferramentas para análise detalhada do sistema de recomendação,
4//! incluindo breakdowns de score, relatórios estatísticos e métricas de influência.
5//!
6//! **Uso:** Destinado principalmente para desenvolvimento, debugging e análise de performance.
7
8use super::core::{
9    GameWithDetails, RecommendationConfig, RecommendationReason, SeriesLimit, UserPreferenceVector,
10    UserSettings,
11};
12use super::scoring::{normalize_score, score_game_cb_detailed, DetailedScoreComponents};
13use serde::Serialize;
14use std::collections::{HashMap, HashSet};
15
16// === ESTRUTURAS DE ANÁLISE ===
17
18/// Breakdown detalhado de score por componentes e roles
19#[derive(Debug, Serialize, Clone)]
20pub struct DetailedScoreBreakdown {
21    pub game_id: String,
22    pub game_title: String,
23    pub steam_app_id: Option<u32>,
24
25    // Scores por role
26    pub affinity_score: f32,
27    pub context_score: f32,
28    pub diversity_score: f32,
29
30    // Scores por componente
31    pub genre_score: f32,
32    pub tag_score: f32,
33    pub series_score: f32,
34
35    // Totais
36    pub total_cb: f32,
37    pub total_cf: f32,
38
39    // Normalizados
40    pub normalized_cb: f32,
41    pub normalized_cf: f32,
42
43    // Ponderados
44    pub weighted_cb: f32,
45    pub weighted_cf: f32,
46
47    // Penalizações/multiplicadores
48    pub age_penalty: f32,
49
50    // Final
51    pub final_score: f32,
52    pub final_rank: usize,
53
54    // Explicação
55    pub reason_label: String,
56    pub reason_type: String,
57
58    // Top contribuições
59    pub top_genres: Vec<(String, f32)>,
60    pub top_affinity_tags: Vec<(String, f32)>,
61    pub top_context_tags: Vec<(String, f32)>,
62}
63
64/// Relatório completo de análise
65#[derive(Debug, Serialize, Clone)]
66pub struct RecommendationAnalysisReport {
67    pub timestamp: String,
68    pub total_games: usize,
69    pub config: RecommendationConfig,
70    pub user_settings: UserSettingsReport,
71
72    // Debug: Estatísticas do perfil
73    pub profile_stats: ProfileStats,
74
75    // Estatísticas gerais
76    pub stats: AnalysisStats,
77
78    // Breakdown individual de cada jogo
79    pub games: Vec<DetailedScoreBreakdown>,
80
81    // Análise agregada
82    pub tag_influence: Vec<(String, TagInfluence)>,
83    pub genre_influence: Vec<(String, GenreInfluence)>,
84    pub reason_distribution: HashMap<String, usize>,
85}
86
87#[derive(Debug, Serialize, Clone)]
88pub struct ProfileStats {
89    pub total_genres: usize,
90    pub total_tags: usize,
91    pub total_series: usize,
92    pub top_genres: Vec<(String, f32)>,
93    pub top_tags: Vec<(String, String, f32)>, // (slug, name, weight)
94}
95
96#[derive(Debug, Serialize, Clone)]
97pub struct UserSettingsReport {
98    pub filter_adult_content: bool,
99    pub series_limit: String,
100}
101
102#[derive(Debug, Serialize, Clone)]
103pub struct AnalysisStats {
104    pub avg_final_score: f32,
105    pub median_final_score: f32,
106    pub max_final_score: f32,
107    pub min_final_score: f32,
108
109    pub avg_cb_score: f32,
110    pub avg_cf_score: f32,
111
112    pub avg_affinity_score: f32,
113    pub avg_context_score: f32,
114    pub avg_diversity_score: f32,
115
116    pub avg_genre_score: f32,
117    pub avg_tag_score: f32,
118    pub avg_series_score: f32,
119
120    pub avg_age_penalty: f32,
121
122    // Proporções
123    pub affinity_proportion: f32,
124    pub context_proportion: f32,
125    pub diversity_proportion: f32,
126    pub genre_proportion: f32,
127}
128
129#[derive(Debug, Serialize, Clone)]
130pub struct TagInfluence {
131    pub tag_name: String,
132    pub category: String,
133    pub role: String,
134    pub games_count: usize,
135    pub avg_contribution: f32,
136    pub max_contribution: f32,
137    pub times_as_reason: usize,
138}
139
140#[derive(Debug, Serialize, Clone)]
141pub struct GenreInfluence {
142    pub games_count: usize,
143    pub avg_contribution: f32,
144    pub max_contribution: f32,
145    pub times_as_reason: usize,
146}
147
148// === FUNÇÃO PRINCIPAL DE ANÁLISE ===
149
150/// Gera relatório completo de análise de recomendações
151///
152/// Esta função executa todo o pipeline de recomendação e coleta
153/// informações detalhadas sobre scores, contribuições e razões.
154pub fn generate_analysis_report(
155    profile: &UserPreferenceVector,
156    candidates: &[GameWithDetails],
157    cf_scores: &HashMap<u32, f32>,
158    ignored_ids: &HashSet<String>,
159    config: RecommendationConfig,
160    user_settings: UserSettings,
161) -> RecommendationAnalysisReport {
162    use super::filtering::apply_hard_filters;
163    use chrono::Local;
164
165    // Estágio 1: Filtros
166    let filtered = apply_hard_filters(candidates, &user_settings);
167
168    // Calcular scores detalhados
169    let raw_results: Vec<_> = filtered
170        .iter()
171        .filter(|g| !ignored_ids.contains(&g.game.id))
172        .map(|g| {
173            let (cb_score, cb_reason, components) = score_game_cb_detailed(profile, g, &config);
174
175            let cf_score = g
176                .steam_app_id
177                .and_then(|id| cf_scores.get(&id))
178                .cloned()
179                .unwrap_or(0.0);
180
181            (g.clone(), cb_score, cf_score, cb_reason, components)
182        })
183        .collect();
184
185    // Normalização
186    let max_cb = raw_results
187        .iter()
188        .map(|(_, c, _, _, _)| *c)
189        .fold(0.0, f32::max);
190    let max_cf = raw_results
191        .iter()
192        .map(|(_, _, c, _, _)| *c)
193        .fold(0.0, f32::max);
194
195    // Processar e criar breakdowns
196    let mut games_breakdowns = create_score_breakdowns(raw_results, max_cb, max_cf, &config);
197
198    // Ordenar por score final
199    games_breakdowns.sort_by(|a, b| b.final_score.partial_cmp(&a.final_score).unwrap());
200
201    // Atualizar ranks
202    for (idx, game) in games_breakdowns.iter_mut().enumerate() {
203        game.final_rank = idx + 1;
204    }
205
206    // Calcular estatísticas
207    let stats = calculate_stats(&games_breakdowns);
208
209    // Analisar influência de tags
210    let tag_influence = analyze_tag_influence(&games_breakdowns, candidates);
211
212    // Analisar influência de gêneros
213    let genre_influence = analyze_genre_influence(&games_breakdowns);
214
215    // Distribuição de razões
216    let reason_distribution = calculate_reason_distribution(&games_breakdowns);
217
218    // Estatísticas do perfil
219    let profile_stats = calculate_profile_stats(profile);
220
221    RecommendationAnalysisReport {
222        timestamp: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
223        total_games: games_breakdowns.len(),
224        config: config.clone(),
225        user_settings: UserSettingsReport {
226            filter_adult_content: user_settings.filter_adult_content,
227            series_limit: match user_settings.series_limit {
228                SeriesLimit::None => "none".to_string(),
229                SeriesLimit::Moderate => "moderate".to_string(),
230                SeriesLimit::Aggressive => "aggressive".to_string(),
231            },
232        },
233        profile_stats,
234        stats,
235        games: games_breakdowns,
236        tag_influence,
237        genre_influence,
238        reason_distribution,
239    }
240}
241
242// === FUNÇÕES AUXILIARES ===
243
244fn create_score_breakdowns(
245    raw_results: Vec<(
246        GameWithDetails,
247        f32,
248        f32,
249        Option<RecommendationReason>,
250        DetailedScoreComponents,
251    )>,
252    max_cb: f32,
253    max_cf: f32,
254    config: &RecommendationConfig,
255) -> Vec<DetailedScoreBreakdown> {
256    raw_results
257        .into_iter()
258        .enumerate()
259        .filter_map(|(idx, (g, cb, cf, cb_reason, components))| {
260            if cb == 0.0 && cf == 0.0 {
261                return None;
262            }
263
264            let cb_n = normalize_score(cb, max_cb);
265            let cf_n = normalize_score(cf, max_cf);
266
267            let weighted_cb = cb_n * config.content_weight;
268            let weighted_cf = cf_n * config.collaborative_weight;
269
270            let final_score = weighted_cb + weighted_cf;
271
272            // Determinar razão final
273            let (reason_label, reason_type) = determine_reason(weighted_cb, weighted_cf, cb_reason);
274
275            Some(DetailedScoreBreakdown {
276                game_id: g.game.id.clone(),
277                game_title: g.game.name.clone(),
278                steam_app_id: g.steam_app_id,
279
280                affinity_score: components.affinity_score,
281                context_score: components.context_score,
282                diversity_score: components.diversity_score,
283
284                genre_score: components.genre_score,
285                tag_score: components.tag_score,
286                series_score: components.series_score,
287
288                total_cb: cb,
289                total_cf: cf,
290
291                normalized_cb: cb_n,
292                normalized_cf: cf_n,
293
294                weighted_cb,
295                weighted_cf,
296
297                age_penalty: components.age_penalty,
298
299                final_score,
300                final_rank: idx + 1,
301
302                reason_label,
303                reason_type,
304
305                top_genres: components.top_genres,
306                top_affinity_tags: components.top_affinity_tags,
307                top_context_tags: components.top_context_tags,
308            })
309        })
310        .collect()
311}
312
313fn determine_reason(
314    weighted_cb: f32,
315    weighted_cf: f32,
316    cb_reason: Option<RecommendationReason>,
317) -> (String, String) {
318    match (weighted_cb > 0.0, weighted_cf > 0.0) {
319        (true, true) => (
320            "Afinidade + Popular na comunidade".to_string(),
321            "hybrid".to_string(),
322        ),
323        (false, true) => ("Popular na comunidade".to_string(), "community".to_string()),
324        _ => {
325            if let Some(reason) = cb_reason {
326                (reason.label, reason.type_id)
327            } else {
328                ("Baseado no seu perfil".to_string(), "general".to_string())
329            }
330        }
331    }
332}
333
334fn calculate_stats(games: &[DetailedScoreBreakdown]) -> AnalysisStats {
335    if games.is_empty() {
336        return AnalysisStats {
337            avg_final_score: 0.0,
338            median_final_score: 0.0,
339            max_final_score: 0.0,
340            min_final_score: 0.0,
341            avg_cb_score: 0.0,
342            avg_cf_score: 0.0,
343            avg_affinity_score: 0.0,
344            avg_context_score: 0.0,
345            avg_diversity_score: 0.0,
346            avg_genre_score: 0.0,
347            avg_tag_score: 0.0,
348            avg_series_score: 0.0,
349            avg_age_penalty: 1.0,
350            affinity_proportion: 0.0,
351            context_proportion: 0.0,
352            diversity_proportion: 0.0,
353            genre_proportion: 0.0,
354        };
355    }
356
357    let n = games.len() as f32;
358    let sum_final: f32 = games.iter().map(|g| g.final_score).sum();
359    let sum_cb: f32 = games.iter().map(|g| g.total_cb).sum();
360    let sum_cf: f32 = games.iter().map(|g| g.total_cf).sum();
361    let sum_affinity: f32 = games.iter().map(|g| g.affinity_score).sum();
362    let sum_context: f32 = games.iter().map(|g| g.context_score).sum();
363    let sum_diversity: f32 = games.iter().map(|g| g.diversity_score).sum();
364    let sum_genre: f32 = games.iter().map(|g| g.genre_score).sum();
365    let sum_tag: f32 = games.iter().map(|g| g.tag_score).sum();
366    let sum_series: f32 = games.iter().map(|g| g.series_score).sum();
367    let sum_age_penalty: f32 = games.iter().map(|g| g.age_penalty).sum();
368
369    let total_cb = sum_affinity + sum_context + sum_diversity;
370    let (affinity_prop, context_prop, diversity_prop, genre_prop) = if total_cb > 0.0 {
371        (
372            (sum_affinity / total_cb) * 100.0,
373            (sum_context / total_cb) * 100.0,
374            (sum_diversity / total_cb) * 100.0,
375            (sum_genre / total_cb) * 100.0,
376        )
377    } else {
378        (0.0, 0.0, 0.0, 0.0)
379    };
380
381    let median_final_score = calculate_median(games);
382
383    AnalysisStats {
384        avg_final_score: sum_final / n,
385        median_final_score,
386        max_final_score: games.iter().map(|g| g.final_score).fold(0.0, f32::max),
387        min_final_score: games.iter().map(|g| g.final_score).fold(f32::MAX, f32::min),
388        avg_cb_score: sum_cb / n,
389        avg_cf_score: sum_cf / n,
390        avg_affinity_score: sum_affinity / n,
391        avg_context_score: sum_context / n,
392        avg_diversity_score: sum_diversity / n,
393        avg_genre_score: sum_genre / n,
394        avg_tag_score: sum_tag / n,
395        avg_series_score: sum_series / n,
396        avg_age_penalty: sum_age_penalty / n,
397        affinity_proportion: affinity_prop,
398        context_proportion: context_prop,
399        diversity_proportion: diversity_prop,
400        genre_proportion: genre_prop,
401    }
402}
403
404fn calculate_median(games: &[DetailedScoreBreakdown]) -> f32 {
405    let mut sorted_scores: Vec<f32> = games.iter().map(|g| g.final_score).collect();
406    sorted_scores.sort_by(|a, b| a.partial_cmp(b).unwrap());
407
408    if sorted_scores.len() % 2 == 0 {
409        let mid = sorted_scores.len() / 2;
410        (sorted_scores[mid - 1] + sorted_scores[mid]) / 2.0
411    } else {
412        sorted_scores[sorted_scores.len() / 2]
413    }
414}
415
416fn analyze_tag_influence(
417    games: &[DetailedScoreBreakdown],
418    candidates: &[GameWithDetails],
419) -> Vec<(String, TagInfluence)> {
420    let mut tag_data: HashMap<String, (String, String, Vec<f32>, usize)> = HashMap::new();
421
422    // Coletar dados de todas as tags
423    for game in games {
424        // Encontrar o jogo original para pegar as tags
425        if let Some(original) = candidates.iter().find(|c| c.game.id == game.game_id) {
426            // Processar affinity tags
427            for (tag_name, contribution) in &game.top_affinity_tags {
428                // Encontrar a tag original para pegar category e role
429                if let Some(tag) = original.tags.iter().find(|t| &t.name == tag_name) {
430                    let entry = tag_data.entry(tag_name.clone()).or_insert((
431                        format!("{:?}", tag.category),
432                        "affinity".to_string(),
433                        Vec::new(),
434                        0,
435                    ));
436                    entry.2.push(*contribution);
437                }
438            }
439
440            // Verificar se foi razão principal
441            if game.reason_type == "tag" && game.reason_label.starts_with("Tag: ") {
442                let tag_name = game.reason_label.strip_prefix("Tag: ").unwrap();
443                if let Some(entry) = tag_data.get_mut(tag_name) {
444                    entry.3 += 1;
445                }
446            }
447        }
448    }
449
450    // Converter para vetor e calcular médias
451    let mut result: Vec<(String, TagInfluence)> = tag_data
452        .into_iter()
453        .map(|(name, (category, role, contributions, times_as_reason))| {
454            let avg = if !contributions.is_empty() {
455                contributions.iter().sum::<f32>() / contributions.len() as f32
456            } else {
457                0.0
458            };
459            let max = contributions.iter().cloned().fold(0.0, f32::max);
460
461            (
462                name.clone(),
463                TagInfluence {
464                    tag_name: name,
465                    category,
466                    role,
467                    games_count: contributions.len(),
468                    avg_contribution: avg,
469                    max_contribution: max,
470                    times_as_reason,
471                },
472            )
473        })
474        .collect();
475
476    // Ordenar por contribuição média (decrescente)
477    result.sort_by(|a, b| {
478        b.1.avg_contribution
479            .partial_cmp(&a.1.avg_contribution)
480            .unwrap()
481    });
482
483    result
484}
485
486fn analyze_genre_influence(games: &[DetailedScoreBreakdown]) -> Vec<(String, GenreInfluence)> {
487    let mut genre_data: HashMap<String, (Vec<f32>, usize)> = HashMap::new();
488
489    for game in games {
490        for (genre_name, contribution) in &game.top_genres {
491            let entry = genre_data
492                .entry(genre_name.clone())
493                .or_insert((Vec::new(), 0));
494            entry.0.push(*contribution);
495        }
496
497        // Verificar se foi razão principal
498        if game.reason_type == "genre" && game.reason_label.starts_with("Gênero: ") {
499            if let Some(genre_name) = game.reason_label.strip_prefix("Gênero: ") {
500                if let Some(entry) = genre_data.get_mut(genre_name) {
501                    entry.1 += 1;
502                }
503            }
504        }
505    }
506
507    let mut result: Vec<(String, GenreInfluence)> = genre_data
508        .into_iter()
509        .map(|(name, (contributions, times_as_reason))| {
510            let avg = if !contributions.is_empty() {
511                contributions.iter().sum::<f32>() / contributions.len() as f32
512            } else {
513                0.0
514            };
515            let max = contributions.iter().cloned().fold(0.0, f32::max);
516
517            (
518                name,
519                GenreInfluence {
520                    games_count: contributions.len(),
521                    avg_contribution: avg,
522                    max_contribution: max,
523                    times_as_reason,
524                },
525            )
526        })
527        .collect();
528
529    result.sort_by(|a, b| {
530        b.1.avg_contribution
531            .partial_cmp(&a.1.avg_contribution)
532            .unwrap()
533    });
534
535    result
536}
537
538fn calculate_reason_distribution(games: &[DetailedScoreBreakdown]) -> HashMap<String, usize> {
539    let mut distribution = HashMap::new();
540    for game in games {
541        *distribution.entry(game.reason_label.clone()).or_insert(0) += 1;
542    }
543    distribution
544}
545
546fn calculate_profile_stats(profile: &UserPreferenceVector) -> ProfileStats {
547    let mut profile_genres: Vec<(String, f32)> = profile
548        .genres
549        .iter()
550        .map(|(k, v)| (k.clone(), *v))
551        .collect();
552    profile_genres.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
553
554    let mut profile_tags_temp: Vec<(String, f32)> = profile
555        .tags
556        .iter()
557        .map(|(k, v)| (format!("{:?}:{}", k.category, k.slug), *v))
558        .collect();
559    profile_tags_temp.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
560
561    let profile_tags: Vec<(String, String, f32)> = profile_tags_temp
562        .into_iter()
563        .take(20)
564        .map(|(key, val)| {
565            let parts: Vec<&str> = key.split(':').collect();
566            (parts.get(1).unwrap_or(&"").to_string(), key.clone(), val)
567        })
568        .collect();
569
570    ProfileStats {
571        total_genres: profile.genres.len(),
572        total_tags: profile.tags.len(),
573        total_series: profile.series.len(),
574        top_genres: profile_genres.into_iter().take(10).collect(),
575        top_tags: profile_tags,
576    }
577}